package de.stefanocke.roo.wicket.xml;

import java.util.ArrayList;
import java.util.List;

import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NamedNodeMap;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;

/**
 * An XML round-trip util that does not use hash codes but only a marker
 * attribute which tells roo whether to manage the element or not. Elements are
 * identified by their wicket:id attribute.
 * 
 */
public abstract class XmlRoundTripUtils {

	private static final String NAME_PREFIX = "name::";

	/**
	 * This method will compare the original document with the proposed document
	 * and return an adjusted document if necessary. Adjustments are only made
	 * if new elements or attributes are proposed. Changes to the order of
	 * attributes or elements in the original document will not result in an
	 * adjustment.
	 * 
	 * @param original
	 *            document as read from the file system
	 * @param proposed
	 *            document as determined by the JspViewManager
	 * @return the new document if changes are necessary, null if no changes are
	 *         necessary
	 */
	public static boolean compareDocuments(Document original, Document proposed) {
		boolean originalDocumentAdjusted = false;
		originalDocumentAdjusted = checkNamespaces(original, proposed, originalDocumentAdjusted);
		originalDocumentAdjusted = addOrUpdateElements(original.getDocumentElement(), proposed.getDocumentElement(),
				originalDocumentAdjusted);
		originalDocumentAdjusted = removeElements(original.getDocumentElement(), proposed.getDocumentElement(),
				originalDocumentAdjusted);
		return originalDocumentAdjusted;
	}

	/**
	 * Compare necessary namespace declarations between original and proposed
	 * document, if namespaces in the original are missing compared to the
	 * proposed, we add them to the original.
	 * 
	 * @param original
	 *            document as read from the file system
	 * @param proposed
	 *            document as determined by the JspViewManager
	 * @return the new document if changes are necessary, null if no changes are
	 *         necessary
	 */
	private static boolean checkNamespaces(Document original, Document proposed, boolean originalDocumentChanged) {
		NamedNodeMap nsNodes = proposed.getDocumentElement().getAttributes();
		for (int i = 0; i < nsNodes.getLength(); i++) {
			if (0 == original.getDocumentElement().getAttribute(nsNodes.item(i).getNodeName()).length()) {
				original.getDocumentElement().setAttribute(nsNodes.item(i).getNodeName(),
						nsNodes.item(i).getNodeValue());
				originalDocumentChanged = true;
			}
		}
		return originalDocumentChanged;
	}

	private static boolean addOrUpdateElements(Element original, Element proposed, boolean originalDocumentChanged) {
		return addOrUpdateElements(original, proposed, new ArrayList<String>(), originalDocumentChanged);
	}

	private static boolean addOrUpdateElements(Element original, Element proposed, List<String> proposedPath,
			boolean originalDocumentChanged) {
		NodeList proposedChildren = proposed.getChildNodes();
		for (int i = 0; i < proposedChildren.getLength(); i++) {
			// check proposed elements and compare to originals to find out if
			// we need to add or replace elements
			Node node = proposedChildren.item(i);
			if (node.getNodeType() == Node.ELEMENT_NODE) {
				Element proposedElement = (Element) node;
				String proposedId = proposedElement.getAttribute("roo:id");
				String byName = proposedElement.getAttribute("roo:findByName");
				if ("true".equals(byName) && proposedId.isEmpty()) {
					proposedId = NAME_PREFIX + proposedElement.getTagName();
				}

				List<String> proposedPathChild = new ArrayList<String>(proposedPath);
				if (proposedId.length() != 0) {
					// only proposed elements with a roo:id will be considered
					proposedPathChild.add(proposedId);

					Element originalElement = findElementByPath(proposedPathChild, original, true);

					if (null == originalElement) {
						// insert proposed element given the original document
						// has no element with a matching id
						// find the best place to insert the element
						Element originalParent = findElementByPath(proposedPath, original, true);

						if (originalParent == null) { // last resort. should
														// never happen, since
														// we create all
														// parents...
							originalParent = original;
						}
						originalParent.appendChild(original.getOwnerDocument().importNode(proposedElement, false));

						originalDocumentChanged = true;
					} else {
						// we found an element in the original document with a
						// matching roo or wicket id
						if (!originalElement.getAttribute("roo:id").isEmpty()
								&& !"true".equals(originalElement.getAttribute("roo:noUpdate"))
								&& !"true".equals(proposedElement.getAttribute("roo:noUpdate"))) {
							// the element has a roo id. That is, it shall be
							// updated by roo. But only, if there is no
							// roo:noUpdate="true"
							// attribute in original or proposed element
							if (!equalElements(originalElement, proposedElement)) {
								// check if the elements have equal name
								// and attributes. If not, update the element.
								// Keep the children as they are.
								NodeList originalElementChildren = originalElement.getChildNodes();

								Node importedNode = original.getOwnerDocument().importNode(proposedElement, false);
								for (int c = 0; c < originalElementChildren.getLength(); c++) {
									Node child = originalElementChildren.item(c).cloneNode(true);
									importedNode.appendChild(child);
								}

								originalElement.getParentNode().replaceChild(importedNode, originalElement); // replace
																												// the
																												// original
																												// with
																												// the
																												// proposed
																												// element
								originalDocumentChanged = true;
							}
						}
					}

				}
				originalDocumentChanged = addOrUpdateElements(original, proposedElement, proposedPathChild,
						originalDocumentChanged); // walk through the document
													// tree recursively
			}
		}
		return originalDocumentChanged;
	}

	/**
	 * Finds an element by the path of the proposed element. While the path of
	 * the proposed element consists of the roo:id attributes of the element and
	 * all its parents, the path in the original document can consist of roo:id
	 * or wicket:id attributes, since the user might have deleted the roo:id
	 * attribute, to tell Roo not to touch the element anymore.
	 * 
	 * Note: If the path is empty list, the given element is returned.
	 * 
	 * @param path
	 * @param root
	 * @return
	 */
	private static Element findElementByPath(List<String> path, Element root, boolean considerWicketId) {
		Element current = root;
		for (String id : path) {
			current = findElementByWicketOrRooIdOrName(id, current, considerWicketId);
			if (current == null) {
				return null;
			}
		}
		return current;
	}

	/**
	 * Finds an element by the wicketId, the rooId, or the local name. To find
	 * it by local name, the id must be prefixed with "name::".
	 * 
	 * @param id
	 * @param element
	 * @param considerWicketId
	 * @return
	 */
	private static Element findElementByWicketOrRooIdOrName(String id, Element element, boolean considerWicketId) {

		NodeList childNodes = element.getChildNodes();
		for (int i = 0; i < childNodes.getLength(); i++) {
			Node n = childNodes.item(i);
			if (n.getNodeType() == Node.ELEMENT_NODE) {
				Element child = (Element) n;

				if (id.startsWith(NAME_PREFIX)) {
					String name = id.substring(NAME_PREFIX.length());
					if (name.equals(child.getTagName())) {
						return child;
					}
				} else {
					String rooId = child.getAttribute("roo:id");
					String wicketId = considerWicketId ? child.getAttribute("wicket:id") : "";

					if (rooId.equals(id) || (rooId.isEmpty() && wicketId.equals(id))) {
						// found!
						return child;
					} else if (rooId.isEmpty()
					/**
					 * && wicketId.isEmpty() ... we ignore wicketIds here to
					 * allow manual insertion of borders etc
					 */
					) {
						// the element has no roo id. Search deeper.
						Element found = findElementByWicketOrRooIdOrName(id, child, considerWicketId);
						if (found != null) {
							return found;
						}
					}
				}
			}
		}

		return null;

	}

	private static boolean removeElements(Element original, Element proposed, boolean originalDocumentChanged) {
		return removeElements(original, proposed, new ArrayList<String>(), originalDocumentChanged);
	}

	private static boolean removeElements(Element original, Element proposed, List<String> originalPath,
			boolean originalDocumentChanged) {
		NodeList originalChildren = original.getChildNodes();
		for (int i = 0; i < originalChildren.getLength(); i++) {
			// check original elements and compare to proposed to find out if we
			// need to remove elements
			Node node = originalChildren.item(i);
			if (node.getNodeType() == Node.ELEMENT_NODE) {
				Element originalElement = (Element) node;
				List<String> originalPathChild = new ArrayList<String>(originalPath);
				String originalRooId = originalElement.getAttribute("roo:id");
				/**
				 * String originalId = originalRooId.isEmpty() ? originalElement
				 * .getAttribute("wicket:id") : originalRooId;
				 */
				String originalId = originalRooId;

				if (!originalId.isEmpty()) {
					originalPathChild.add(originalId);
				}

				if (!originalRooId.isEmpty()) {
					// only original elements with a roo id will be considered
					// Is the element present in the proposed document?
					Element proposedElement = findElementByPath(originalPathChild, proposed, false);
					if (null == proposedElement) {
						// not found. delete it in original document
						originalElement.getParentNode().removeChild(originalElement);
						originalDocumentChanged = true;
					}
				}
				originalDocumentChanged = removeElements(originalElement, proposed, originalPathChild,
						originalDocumentChanged);
				// walk through the document tree recursively
			}
		}
		return originalDocumentChanged;
	}

	private static boolean equalElements(Element a, Element b) {
		if (!a.getTagName().equals(b.getTagName())) {
			return false;
		}
		NamedNodeMap attributes = a.getAttributes();

		for (int i = 0; i < attributes.getLength(); i++) {
			Node node = attributes.item(i);

			if ((b.getAttribute(node.getNodeName()).length() == 0 || !b.getAttribute(node.getNodeName()).equals(
					node.getNodeValue()))) {
				return false;
			}

		}
		if (a.getAttributes().getLength() != b.getAttributes().getLength()) {
			return false;
		}
		return true;
	}

}
